<!DOCTYPE html><meta charset="utf-8">
<meta name=viewport content="width=900">
<title>Web graphics workload simulator</title>
<style>
body {
  margin: 0;
  font-family: monospace;
  user-select: none;
  font-size: 16px;
  text-size-adjust: none;
}
pre { margin: 0; display: inline}
.square img { position: relative; }
.square {
  overflow: hidden;
  border-bottom: 20px solid black;
  border-left: 20px solid black;
  display: block;
  float: right;
  margin-left: 40px;
  margin-bottom: 20px;
}

summary {
  font-weight:  bold;
  font-size:  130%;
  position: relative;
  left: -20px;
}
details {
  margin-left: 20px;
  padding: 15px 0px;
}

#fpsSpan { font-size: 30px; }

input, label { white-space: pre; pointer-events: auto; }
input { margin: 10px 10px;  transform: scale(2); }
input[type="number"] { width: 50px; }
input[type="number"] { margin:  20px 20px; }
input[type="range"] {
    width: 200px;
    margin: 10px 110px;
    padding: 0px;
    margin-right:  110px;
}

@media (pointer: coarse) {
  .square {
    float: none;
    border-left: none;
    border-right:  20px solid black;
    margin-left: 0;
  }
  input[type="radio"] { margin:  15px 15px; }
  input[type="range"] {
      width: 100px;
      margin: 30px 200px;
      padding: 0px;
      transform: scale(4);
  }
  body {
    font-size: 22px;
  }
}

.rotate { animation: rotating 2s linear infinite; }
@keyframes rotating {
  from { transform: scale(2) rotate(0deg); }
  to { transform: scale(2) rotate(360deg); }
}

.bad { color: red; user-select: text; }
.light { color: #CCCCCC; }
</style>
<canvas id=webglCanvas width=512 height=512 class=square></canvas>
<canvas id=webGPUCanvas width=512 height=512 class=square style=display:none;></canvas>
<canvas id=canvas class=square style=display:none;></canvas>
<div id=dom class=square style=display:none;background:white>
  <img id=img style=width:256px;height:256px;background:black></img>
</div>
<div style="margin:0 2em; padding-top:2em">
<h2 style=margin-top:0>Drag the logo, or choose "Animate".</h2>
<label for=animate><input type=checkbox name=animate id=animate>Animate</label>
<span id=continuousOptions style=display:none;>
  <label for=useRaf><input type=radio name=loop id=useRaf checked>requestAnimationFrame</label>
  <label for=usePostMessage><input type=radio name=loop id=usePostMessage>postMessage</label>
  <label for=useSetTimeout><input type=radio name=loop id=useSetTimeout>setTimeout</label>
  <label for=useSetInterval><input type=radio name=loop id=useSetInterval>setInterval</label>
  <br>
  <div id=fpsOptions>
    <input id=fpsSlider type=range min=10 max=370 step=10 value=60>
    <pre>Target FPS: <span id=fpsLabel>60</span></pre>
  </div>
</span>
<div id=fpsPanel>
  <label for=showFps><input type=checkbox id=showFps checked>Show FPS</label>
  <span id=fps>
    <span id=fpsSpan></span>
    <br>
    <label for=showStats><input type=checkbox id=showStats>More performance info</label>
    <div id=stats></div>
  </span>
</div>
<p><span class=bad id=errorMessage></span><span class=bad id=bufferWarning></span></p>
<label for=useWebGPU>Renderer: <input type=radio name=renderer id=useWebGPU checked>WebGPU</label>
<label for=useGl><input type=radio name=renderer id=useGl>WebGL</label>
<label for=use2D><input type=radio name=renderer id=use2D>Canvas 2D</label>
<label for=useDom><input type=radio name=renderer id=useDom>DOM</label>
<br>
<details id=canvasOptions>
  <summary>Canvas options</summary>
    Canvas size <input type=number id=canvasSize value=512 min=1> pixels<sup>2</sup>
  <div id=offscreenCanvasOptions>
    <label for=onscreen><input type=radio name=onscreen id=onscreen checked>Regular canvas</label>
    <label for=offscreen><input type=radio name=onscreen id=offscreen>OffscreenCanvas (on main thread)</label>
    <!-- <label for=offscreenWorker><input type=radio name=offscreen id=offscreenWorker>OffscreenCanvas on worker</label> -->
    <br>
    <label for=transferControlToOffscreen><input type=checkbox id=transferControlToOffscreen checked>Use transferControlToOffscreen</label>
  </div>
</details>
<div id=renderingOptions>
  <details id=renderingWork>
    <summary>Extra rendering work</summary>
    <div id=pixelsWrapper>
      <input type=range id=pixels min=65536 max=65536000 value=65536 step=65536>
      <pre>draw <span id=pixelsLabel>64K</span> pixels</pre>
    </div>
    <input type=range id=drawCalls min=0 max=10000 value=0 step=10>
    <pre>using <span id=drawCallsLabel>1</span> draw call(s)</pre>
    <br>
    Multiply the above slider values by:
    <label for=x1><input type=radio name=multiplier id=x1 checked>1 </label>
    <label for=x10><input type=radio name=multiplier id=x10>10 </label>
    <label for=x100><input type=radio name=multiplier id=x100>100 </label>
    <br>
    <label for=useRenderBundles id=useRenderBundlesLabel><input type=checkbox name=useRenderBundles id=useRenderBundles> use RenderBundles</label>
  </details>
  <details id=uploadOptions>
    <summary>Data uploads (e.g. bufferData, mapAsync)</summary>
    <input type=range id=bufferData min=0 max=256 value=0 step=0.25>
    <pre><span id=bufferDataLabel>0.00</span> MB mappedAtCreation/bufferData per frame</pre>
    <br>
    <input type=range id=bufferSubData min=0 max=256 value=0 step=0.25>
    <pre><span id=bufferSubDataLabel>0.00</span> MB queue.writeBuffer/bufferSubData per frame</pre>
    <br>
    <input type=range id=mapAsync min=0 max=256 value=0 step=0.25>
    <pre><span id=mapAsyncLabel>0.00</span> MB mapAsync/bufferData per frame</pre>
    <br>
    <label for=finish><input type=checkbox id=finish>glFinish</label>
    <br>
    <label for=readPixels><input type=checkbox id=readPixels>glReadPixels</label>
  </details>
  <details id=webglContextCreation>
    <summary>WebGL context creation options</summary>
    <label for=useWebGL2><input type=checkbox id=useWebGL2 checked>Use WebGL 2 if available</label>
    <label for=antialias><input type=checkbox id=antialias checked>Antialias</label>
    <label for=alpha><input type=checkbox id=alpha checked>Alpha</label>
    <label for=depth><input type=checkbox id=depth checked>Depth</label>
    <label for=stencil><input type=checkbox id=stencil>Stencil</label>
    <label for=premultipliedAlpha><input type=checkbox id=premultipliedAlpha checked>Premultiplied Alpha</label>
    <label for=preserveDrawingBuffer><input type=checkbox id=preserveDrawingBuffer>Preserve Drawing Buffer</label>
    <label for=desynchronized><input type=checkbox id=desynchronized>Desynchronized</label>
    <br>
    Power preference
    <label for=ppDefault><input type=radio name=pp id=ppDefault checked>default</label>
    <label for=lowPower><input type=radio name=pp id=lowPower>low-power</label>
    <label for=highPerformance><input type=radio name=pp id=highPerformance>high-performance</label>
    <br>
    <label for=separateHighPowerContext><input type=checkbox id=separateHighPowerContext>Activate high power GPU (by creating a separate high power context)</label>
  </details>
  </pre>
  <details id=webgpuCanvasOptions>
    <summary>WebGPU canvas context options</summary>
      <label for=multisampling><input type=checkbox name=multisampling id=multisampling checked>4x Multisampling</label>
      <label for=opaque><input type=checkbox name=opaque id=opaque checked>Opaque</label>
  </details>
  <details id=contextAttributes>
    <summary>WebGL Context attributes</summary>
    <pre style=user-select:text></pre>
    <pre id=contextVersion style=user-select:text>
  </details>
  <details id=supportedExtensions>
    <summary>Supported WebGL Extensions</summary>
  </details>
  <details id=adapterInfo>
    <summary>WebGPU Adapter information</summary>
    <pre id=featuresAndLimits style=user-select:text></pre>
  </details>
</div>
<br>
<input type=range id=jsWork min=0 max=100 value=0>
<pre><span id=jsWorkLabel>0</span> ms extra Javascript work per frame</pre>
<br>
<label for=animation><input type=checkbox id=animation>CSS animation</label>

<iframe id=highPowerFrame style=display:none></iframe>

<h2><center>Web graphics workload simulator</center></h2>
</div>

<script type="module">
/* eslint-disable */

/************\
* Options UI *
\************/


// Set all input elements with values from the query string.
const controls = document.querySelectorAll('input, details');
const defaultChecked = {};
const defaultValues = {};
const defaultMaxes = {};
for (const control of controls) {
  if (!control.id) continue;
  defaultChecked[control.id] = control.checked;
  defaultValues[control.id] = control.value;
  defaultMaxes[control.id] = control.max;
  const param = window.location.search.match(control.id + '(?:=([^&]*))?');
  if (param) {
    if (control.type == 'radio')
      control.checked = true;
    else if (control.type == 'checkbox')
      control.checked = param[1] != 'false';
    else if (control instanceof HTMLDetailsElement)
      control.open = true;
    else
      control.value = param[1];
  }
  control.oninput = updateControls;
  if (control instanceof HTMLDetailsElement)
    control.onclick = ()=>setTimeout(updateControls, 0);
}
// Some controls require a page reload when changed.
let reloadingPage = false;
const reloadControls = ['useWebGL2', 'antialias', 'alpha', 'depth', 'stencil', 'premultipliedAlpha', 'preserveDrawingBuffer', 'desynchronized', 'ppDefault', 'lowPower', 'highPerformance', 'canvasSize', 'onscreen', 'offscreen', 'transferControlToOffscreen', 'multisampling', 'opaque'].map(x=>window[x]);
for (let control of reloadControls) {
  control.oninput = null;
  control.onchange = ()=>{
    reloadingPage = true;
    updateControls();
    callReplaceStateThrottled();
    location.reload();
  };
}

separateHighPowerContext.onchange = ()=>{
  if (!separateHighPowerContext.checked)
    highPowerFrame.contentDocument.location.reload();
  else {
    const doc = highPowerFrame.contentDocument;
    const canvas = doc.createElement('canvas');
    doc.body.appendChild(canvas);
    canvas.getContext('webgl', {powerPreference: 'high-performance'});
  }
}
separateHighPowerContext.onchange();

animate.addEventListener('change', ()=>render());

let queryString = window.location.search;
let previousQueryString = queryString;
let replaceStateScheduled = false;
function callReplaceStateThrottled() {
  replaceStateScheduled = false;
  if (queryString == previousQueryString)
    return;
  previousQueryString = queryString;
  let path = window.location.pathname;
  history.replaceState(null, null, queryString == '?' ? path : path + queryString);
}
const suffixes = ['', 'K', 'M', 'G', 'E']
const divisors = [];
for (let i = 0; i < suffixes.length; i++)
  divisors[i] = Math.pow(10, i * 3);
const formatSI = (x) => {
  const order = Math.min(Math.log10(Math.abs(x)) / 3 | 0, suffixes.length);
  return (x / divisors[order]).toFixed(1) + suffixes[order];
}
var multiplier;

function updateControls() {
  multiplier = x1.checked ? 1 : x10.checked ? 10 : 100;
  webGPUCanvas.style.display = useWebGPU.checked ? 'block' : 'none';
  webglCanvas.style.display = useGl.checked ? 'block' : 'none';
  canvas.style.display = use2D.checked ? 'block' : 'none';
  dom.style.display = useDom.checked ? 'block' : 'none';
  animation.className = animation.checked ? 'rotate' : null;
  canvasOptions.style.display = useGl.checked || useWebGPU.checked || use2D.checked ? '' : 'none';
  transferControlToOffscreen.parentElement.style.display = onscreen.checked ? 'none' : '';
  continuousOptions.style.display = animate.checked ? '' : 'none';
  renderingOptions.style.display = useGl.checked || useWebGPU.checked ? '' : 'none';
  contextAttributes.style.display = useGl.checked ? '' : 'none';
  supportedExtensions.style.display = useGl.checked ? '' : 'none';
  webglContextCreation.style.display = useGl.checked ? '' : 'none';
  contextVersion.style.display = useGl.checked ? '' : 'none';
  finish.parentElement.style.display = useGl.checked ? '' : 'none';
  readPixels.parentElement.style.display = useGl.checked ? '' : 'none';
  offscreenCanvasOptions.style.display = useGl.checked || use2D.checked ? '' : 'none';
  webgpuCanvasOptions.style.display = useWebGPU.checked ? '' : 'none';
  adapterInfo.style.display = useWebGPU.checked ? '' : 'none';
  fpsOptions.style.display =
      animate.checked && (useSetTimeout.checked || useSetInterval.checked) ? '' : 'none';
  fps.style.visibility = showFps.checked ? 'visible' : 'hidden';
  stats.style.display = showStats.checked ? 'block' : 'none';
  drawCallsLabel.textContent = Math.max(1, drawCalls.value * multiplier);
  useRenderBundlesLabel.style.display = useWebGPU.checked ? '' : 'none';
  pixelsLabel.textContent = formatSI(pixels.value * multiplier);
  jsWorkLabel.textContent = jsWork.value;
  fpsLabel.textContent = fpsSlider.value == fpsSlider.max ? '∞' : fpsSlider.value;
  bufferDataLabel.textContent = parseFloat(bufferData.value).toFixed(2);
  bufferSubDataLabel.textContent = parseFloat(bufferSubData.value).toFixed(2);
  mapAsyncLabel.textContent = parseFloat(mapAsync.value).toFixed(2);

  const queryParams = [];
  for (const control of controls) {
    if (control.type == 'radio') {
      if (!defaultChecked[control.id] && control.checked)
        queryParams.push(control.id);
    } else if (control.type == 'checkbox') {
      if (control.checked != defaultChecked[control.id])
        queryParams.push(defaultChecked[control.id] ? control.id + '=' + control.checked : control.id);
    } else if (control instanceof HTMLDetailsElement) {
      if (control.open)
        queryParams.push(control.id);
    } else if (control.value != defaultValues[control.id]) {
      queryParams.push(control.id + '=' + control.value);
    }
  }
  queryString = '?' + queryParams.join('&');
  if (!replaceStateScheduled) {
    replaceStateScheduled = true;
    setTimeout(callReplaceStateThrottled, 200);
  }
  if (!animate.checked)
    render();
};


/**********************\
* Input event handling *
\**********************/


const imgSize = 256;
const size = parseInt(canvasSize.value);
let webglVersion;

let mouseDown = false;
const lastPos = [0, 0];
document.onmouseup = (e) => { mouseDown = false; }
document.onmousedown = (e) => {
  mouseDown = true;
  lastPos[0] = e.pageX;
  lastPos[1] = e.pageY;
};
document.ontouchstart = (e) => {
  lastPos[0] = e.touches[0].pageX;
  lastPos[1] = e.touches[0].pageY;
}
const position = [(size - imgSize) / 2, (size - imgSize) / 2];
let continuousRunning = false;
let mouseUpdatesThisFrame = 0;
function mouseMove(e) {
  mouseUpdatesThisFrame++;
  countFps("mouse/touchmove event");
  const xy = [0, 0];
  if (e.touches) {
    xy[0] = e.touches[0].pageX;
    xy[1] = e.touches[0].pageY;
  } else {
    xy[0] = e.pageX;
    xy[1] = e.pageY;
  }
  if (e.touches || mouseDown) {
    for (let i = 0; i < 2; ++i) {
      position[i] += xy[i] - lastPos[i];
      position[i] = Math.max(0, Math.min(size - imgSize, position[i]));
      lastPos[i] = xy[i];
    }
    if (!continuousRunning) {
      render();
    }
  }
}
document.addEventListener("mousemove", mouseMove, true);
document.body.addEventListener("touchmove", mouseMove, true);
for (const element of [dom, canvas, webglCanvas, webGPUCanvas, img])
  element.onmousedown = element.ontouchstart = (e)=>e.preventDefault();


/***********\
* Rendering *
\***********/


webGPUCanvas.width = webGPUCanvas.height = webglCanvas.width = webglCanvas.height = canvas.width = canvas.height = size;
dom.style.width = dom.style.height = size + 'px';

window.onmessage = () => render(false /* fromRaf */, true /* fromPostMessage */);

let ctx;

let deviceRequested = false;
let device;
let gpuPresent;
let canvasContext;
let multisampleRenderAttachment;
let sampleCount = multisampling.checked ? 4 : 1;
let pipeline;
let sampler;
let bindGroup;
let uniformBuffer;
let uploadBuffer;
let uploadBufferSize;
let renderBundle;
let renderBundleAfterScissor;
let renderBundleNumDrawCalls;
let renderBundleInstances;
let mapAsyncArray = new Float32Array(0);
let mapAsyncTargetBuffer;
let mapAsyncBufferSize = 0;
let mapAsyncReady = [];
let mapAsyncBuffersOutstanding = 0;
const canvasFormat = 'bgra8unorm';

let bitmapRenderer;
let offscreenCanvas;
let gl;
let glBitmapRenderer;
let glOffscreenCanvas;
let program;
let buffer;
let bufferForSubData;
let bufferForSubDataSize = 0;
const borderSize = 10;
const vertices = [0, 0, 1, 0, 0, 1, 1, 1];
const readPixelsArray = new Uint8Array(4);
let bufferDataArray = new Float32Array(0);
let bufferSubDataArray = new Float32Array(0);

let interval;
let intervalFps;
let timeout = null;
let rafPending = 0;
let postMessagePending = 0;

const animationDirection = [1, 1];
async function render(fromRaf, fromPostMessage) {
  if (reloadingPage) return;
  if (fromRaf) rafPending--;
  if (fromPostMessage) postMessagePending--;

  // Set up the appropriate render loop callback as specified by the UI, if
  // continuous rendering is enabled.
  continuousRunning = animate.checked;
  if (continuousRunning) {
    for (let i = 0; i < 2; ++i) {
      position[i] += animationDirection[i] * 2;
      if (position[i] > size - imgSize) {
        position[i] = size - imgSize;
        animationDirection[i] = -1;
      }
      if (position[i] < 0) {
        position[i] = 0;
        animationDirection[i] = 1;
      }
    }
    if (useRaf.checked && rafPending == 0) {
      window.requestAnimationFrame(function() { render(true); });
      rafPending++;
    }
    if (useSetTimeout.checked) {
      clearTimeout(timeout);
      let time = 1 / fpsSlider.value * 1000;
      if (fpsSlider.value == fpsSlider.max)
        time = 0;
      timeout = setTimeout(render, time);
    }
    if (useSetInterval.checked) {
      if (!interval || intervalFps != fpsSlider.value) {
        clearInterval(interval);
        intervalFps = fpsSlider.value;
        let time = 1 / fpsSlider.value * 1000;
        if (fpsSlider.value == fpsSlider.max)
          time = 0;
        interval = setInterval(render, time);
      }
    } else {
      clearInterval(interval);
      interval = null;
    }
    if (usePostMessage.checked) {
      if (postMessagePending == 0) {
        ++postMessagePending;
        window.postMessage('', '*');
      }
    }
  } else {
    clearInterval(interval);
    interval = null;
  }

  countFps("render", mouseUpdatesThisFrame);
  mouseUpdatesThisFrame = 0;

  // Busy wait for a configurable amount of time.
  const startMs = Date.now();
  while (Date.now() - startMs < jsWork.value);

  // DOM rendering.
  if (useDom.checked) {
    img.style.left = position[0] + 'px';
    img.style.top = position[1] + 'px';
    return; // Done with DOM rendering.
  }
  // 2D canvas rendering.
  if (use2D.checked) {
    if (!ctx) {
      if (offscreen.checked) {
        if (transferControlToOffscreen.checked) {
          offscreenCanvas = canvas.transferControlToOffscreen();
        } else {
          bitmapRenderer = canvas.getContext('bitmaprenderer');
          offscreenCanvas = new OffscreenCanvas(size, size);
        }
        ctx = offscreenCanvas.getContext('2d');
      } else {
        ctx = canvas.getContext('2d');
      }
    }
    ctx.fillStyle = 'white';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    try {
      ctx.drawImage(img, position[0], position[1]);
    } catch (e) {
      ctx.fillStyle = 'black';
      ctx.fillRect(position[0], position[1], imgSize, imgSize);
    }
    if (offscreen.checked && !transferControlToOffscreen.checked && bitmapRenderer && offscreenCanvas) {
      bitmapRenderer.transferFromImageBitmap(offscreenCanvas.transferToImageBitmap());
    }
    return; // Done with 2D canvas rendering.
  }

  // Set up data to upload into buffers, if requested.
  if (bufferData.value > 0) {
    if (bufferDataArray.length * 4 != bufferData.value * 1024 * 1024) {
      bufferDataArray = new Float32Array(bufferData.value * 1024 * 1024 / 4);
      // We want to actually use this data in rendering so the graphics driver
      // can't optimize away the upload. Fill the first few bytes with our real
      // vertex data.
      if (bufferDataArray.length > 0)
        bufferDataArray.set(vertices, 0);
    }
  }

  if (bufferSubDataArray.length * 4 != bufferSubData.value * 1024 * 1024) {
    bufferSubDataArray = new Float32Array(bufferSubData.value * 1024 * 1024 / 4);
    if (bufferSubDataArray.length > 0)
      bufferSubDataArray.set(vertices, 0);
  }

  if (mapAsyncArray.length * 4 != mapAsync.value * 1024 * 1024) {
    mapAsyncArray = new Float32Array(mapAsync.value * 1024 * 1024 / 4);
    if (mapAsyncArray.length > 0)
      mapAsyncArray.set(vertices, 0);
  }

  // Calculate how many instances and draw calls we need based on slider values.
  const numDrawCalls = Math.max(1, drawCalls.value * multiplier);
  const instances = pixels.value / 64000 * multiplier | 0;
  const instancesPerCall = Math.max(1, instances / numDrawCalls | 0);
  const instancesFirstCall = instancesPerCall + numDrawCalls % instancesPerCall;


  /******************\
  * WebGPU rendering *
  \******************/


  if (useWebGPU.checked) {
    // Initialize WebGPU.
    if (!device && navigator.gpu) {
      try {
        if (deviceRequested) return;
        deviceRequested = true;
        let adapter = await navigator.gpu.requestAdapter();
        featuresAndLimits.textContent = JSON.stringify(
          {
            name: adapter.name,
            features: Array.from(adapter.features || []).sort(),
            limits: adapter.limits
          }, 0, 2);
        device = await adapter.requestDevice({});
        device.addEventListener('uncapturederror', (e)=>{
          if (!errorMessage.textContent) errorMessage.textContent = 'Uncaptured error: ' + e.error.message;
        });
        canvasContext = webGPUCanvas.getContext('webgpu');
        canvasContext.configure({
          device,
          format: canvasFormat,
          alphaMode: opaque.checked ? 'opaque' : 'premultiplied'
        });
        const bindGroupLayout = device.createBindGroupLayout({
          entries: [
            {
              binding: 0,
              visibility: GPUShaderStage.VERTEX,
              buffer: { type: 'uniform', },
            },
            {
              binding: 1,
              visibility: GPUShaderStage.FRAGMENT,
              sampler: { type: 'filtering', },
            },
            {
              binding: 2,
              visibility: GPUShaderStage.FRAGMENT,
              texture: { sampleType: 'float', },
            },
          ],
        });
        const pipelineLayout = device.createPipelineLayout({
          bindGroupLayouts: [bindGroupLayout],
        });
        if (multisampling.checked) {
          multisampleRenderAttachment = device.createTexture({
            size: { width: size, height: size },
            sampleCount,
            format: canvasFormat,
            usage: GPUTextureUsage.RENDER_ATTACHMENT,
          })
          if (!multisampleRenderAttachment)
            errorMessage.textContent = 'Failed to allocate multisample render attachment.';
        }
        let shaderModule = device.createShaderModule({ code: `
          const pos = array(vec2f(0, 1),
                            vec2f(0, 0),
                            vec2f(1, 0),
                            vec2f(0, 1),
                            vec2f(1, 0),
                            vec2f(1, 1));

          @group(0) @binding(0) var<uniform> xy: vec2f;
          @group(0) @binding(1) var sampler1: sampler;
          @group(0) @binding(2) var texture1: texture_2d<f32>;

          struct VertexOutput {
            @builtin(position) Position : vec4f,
            @location(0) uv : vec2f,
          };

          @vertex fn vs(@builtin(vertex_index) i : u32) -> VertexOutput {
            return VertexOutput(
              vec4(pos[i] * vec2(1, -1) + xy, 0, 1),
              pos[i]);
          }

          @fragment fn fs(@location(0) uv: vec2f) -> @location(0) vec4f {
            return textureSample(texture1, sampler1, uv);
          }
          `, });

        pipeline = device.createRenderPipeline({
          primitive: { topology: 'triangle-list' },
          layout: pipelineLayout,
          multisample: { count: sampleCount },
          vertex: {
            module: shaderModule,
          },
          fragment: {
            targets: [ { format: canvasFormat, }, ],
            module: shaderModule,
          },
        });
        sampler = device.createSampler({
          magFilter: 'linear',
          minFilter: 'linear',
        });
        const texture = device.createTexture({
          size: [256, 256, 1],
          format: 'rgba8unorm',
          usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
        });
        const loadImage = async () => {
          const imageBitmap = await createImageBitmap(img);
          device.queue.copyExternalImageToTexture(
            { source: imageBitmap }, { texture }, [256, 256, 1]);
          render();
        };
        if (img.src && img.complete) loadImage(); else img.addEventListener('load', loadImage);
        uniformBuffer = device.createBuffer({
          size: 2 * 4,
          usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
        });
        bindGroup = device.createBindGroup({
          layout: bindGroupLayout,
          entries: [
            { binding: 0, resource: { buffer: uniformBuffer } },
            { binding: 1, resource: sampler },
            { binding: 2, resource: texture.createView() },
          ],
        });
      } catch(e) {
        if (!errorMessage.textContent) errorMessage.textContent = "Error initializing WebGPU: " + e;
        return;
      }
    }

    // End WebGPU init.
    // Begin WebGPU rendering.

    if (!device) {
      if (!errorMessage.textContent) errorMessage.textContent = "Error: WebGPU not supported.";
      return;
    }
    try {
      const canvasView = canvasContext.getCurrentTexture().createView();

      device.queue.writeBuffer(uniformBuffer, 0, new Float32Array([position[0] / size * 2 - 1, 1 - position[1] * 2 / size]));
      let buffer = null;

      // Upload data using createBuffer with mappedAtCreation.
      if (bufferData.value > 0) {
        buffer = device.createBuffer({ size: bufferDataArray.byteLength, usage: GPUBufferUsage.VERTEX, mappedAtCreation: true });
        new Float32Array(buffer.getMappedRange()).set(bufferDataArray);
        buffer.unmap();
        // TODO: use this data in rendering so it can't be optimized away
      }

      // Upload data using queue.writeBuffer.
      if (bufferSubData.value > 0) {
        if (!uploadBuffer || uploadBufferSize != bufferSubDataArray.byteLength) {
          if (uploadBuffer) uploadBuffer.destroy();
          uploadBufferSize = bufferSubDataArray.byteLength;
          uploadBuffer = device.createBuffer({
            size: bufferSubDataArray.byteLength,
            usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
          });
        }
        // TODO: Use the uploaded data during rendering.
        device.queue.writeBuffer(uploadBuffer, 0, bufferSubDataArray);
      }

      // Upload data using mapAsync and a queue of staging buffers.
      if (mapAsyncArray.length > 0) {
        if (mapAsyncBufferSize != mapAsyncArray.byteLength) {
          mapAsyncBufferSize = mapAsyncArray.byteLength;
          if (mapAsyncTargetBuffer) mapAsyncTargetBuffer.destroy();
          mapAsyncTargetBuffer = device.createBuffer({size: mapAsyncArray.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST});
        }
        let buffer = null;
        while (mapAsyncReady.length) {
          buffer = mapAsyncReady.shift();
          if (buffer.usedSize == mapAsyncArray.byteLength)
            break;
          buffer.destroy();
          mapAsyncBuffersOutstanding--;
          buffer = null;
          bufferWarning.textContent = '';
        }
        if (!buffer) {
          buffer = device.createBuffer({
            size: mapAsyncArray.byteLength,
            usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.MAP_WRITE,
            mappedAtCreation: true,
          });
          buffer.usedSize = mapAsyncArray.byteLength;
          mapAsyncBuffersOutstanding++;
          if (mapAsyncBuffersOutstanding > 10) {
            bufferWarning.textContent = ` Warning: mapAsync requests from ${mapAsyncBuffersOutstanding} frames ago have not resolved yet. ${(mapAsync.value * mapAsyncBuffersOutstanding).toFixed(2)} MB of staging buffers allocated.`;
          }
        }
        new Float32Array(buffer.getMappedRange()).set(mapAsyncArray);
        buffer.unmap();
        const commandEncoder = device.createCommandEncoder();
        commandEncoder.copyBufferToBuffer(buffer, 0, mapAsyncTargetBuffer, 0, mapAsyncArray.byteLength);
        // TODO: combine this submit with the main one, but we'll have to delay calling mapAsync until after the submit.
        device.queue.submit([commandEncoder.finish()]);
        // TODO: use this data during rendering.
        buffer.mapAsync(GPUMapMode.WRITE).then(()=>mapAsyncReady.push(buffer));
      }

      // Record command buffer.
      const commandEncoder = device.createCommandEncoder();
      let renderAttachment = canvasView;
      if (multisampling.checked) {
        renderAttachment = multisampleRenderAttachment.createView();
      }
      const passEncoder = commandEncoder.beginRenderPass({
        colorAttachments: [ {
          view: renderAttachment,
          resolveTarget: multisampling.checked ? canvasView : undefined,
          loadOp: 'clear',
          clearValue: [1, 1, 1, 1],
          storeOp: multisampling.checked ? 'discard' : 'store',
        }, ],
      });
      if (useRenderBundles.checked && renderBundle && instances == renderBundleInstances && renderBundleNumDrawCalls == numDrawCalls) {
        // If we have valid RenderBundles to use, execute them and we're done.
        // We have two RenderBundles, one for normal rendering and one for
        // scissored rendering.
        passEncoder.executeBundles([renderBundle]);
        passEncoder.setScissorRect(position[0], position[1], 2, 2);
        if (renderBundleAfterScissor) {
          passEncoder.executeBundles([renderBundleAfterScissor]);
        }
      } else {
        let passOrBundleEncoder = passEncoder;
        // If not using render bundles, we'll record directly to the pass encoder.
        // If render bundles are enabled and we get here, that means we need to
        // update our render bundles. Replace the pass encoder with a render bundle
        // encoder, and once the render bundles are recorded then we'll execute them
        // on the pass encoder.
        if (useRenderBundles.checked) {
          // We need to record a new RenderBundle.
          renderBundleInstances = instances;
          renderBundleNumDrawCalls = numDrawCalls;
          passOrBundleEncoder = device.createRenderBundleEncoder({
            colorFormats: [canvasFormat],
            sampleCount,
          });
        }
        passOrBundleEncoder.setPipeline(pipeline);
        passOrBundleEncoder.setBindGroup(0, bindGroup);
        passOrBundleEncoder.draw(6, instancesFirstCall);
        let instancesDrawn = instancesFirstCall;
        let scissorEnabled = false;
        for (let i = 1; i < numDrawCalls; i++) {
          if (!scissorEnabled && instancesDrawn > instances) {
            // If we've drawn all of the requested pixels already, enable the scissor
            // test so we only draw one pixel per draw call for the rest of the calls.
            scissorEnabled = true;
            if (useRenderBundles.checked) {
              // Scissor state is not a part of the render bundle. So we must use
              // two render bundles: one for the draw calls before we enable scissor,
              // and a separate one for the draw calls after we enable scissor.
              // Finish the first render bundle here and start recording the second
              // one.
              renderBundle = passOrBundleEncoder.finish();
              passOrBundleEncoder = device.createRenderBundleEncoder({
                colorFormats: [canvasFormat],
                sampleCount,
              });
              passOrBundleEncoder.setPipeline(pipeline);
              passOrBundleEncoder.setBindGroup(0, bindGroup);
            } else {
              // 1x1 scissor rect seems to fail on my macbook, also in webgl, driver bug?
              passEncoder.setScissorRect(position[0], position[1], 2, 2);
            }
          }
          passOrBundleEncoder.draw(6, instancesPerCall);
          instancesDrawn += instancesPerCall;
        }
        // If we're not using render bundles, we're done. If we're using render bundles,
        // we need to finish and then execute them on the pass encoder.
        if (useRenderBundles.checked) {
          if (scissorEnabled) {
            renderBundleAfterScissor = passOrBundleEncoder.finish();
          } else {
            renderBundle = passOrBundleEncoder.finish();
            renderBundleAfterScissor = null;
          }
          passEncoder.executeBundles([renderBundle]);
          passEncoder.setScissorRect(position[0], position[1], 2, 2);
          if (renderBundleAfterScissor) {
            passEncoder.executeBundles([renderBundleAfterScissor]);
          }
        }
      }
      passEncoder.end();
      if (buffer) buffer.destroy();
      device.queue.submit([commandEncoder.finish()]);
    } catch(e) {
      if (!errorMessage.textContent) errorMessage.textContent = "Error: " + e;
      throw e;
    }
    return; // Done with WebGPU rendering.
  }


  /*****************\
  * WebGL rendering *
  \*****************/


  // Initialize WebGL.
  if (!gl) {
    const options = {};
    for (let option of ['antialias', 'alpha', 'depth', 'stencil', 'premultipliedAlpha', 'preserveDrawingBuffer', 'desynchronized'])
      options[option] = window[option].checked;
    options.powerPreference = ppDefault.checked ? 'default' : lowPower.checked ? 'low-power' : 'high-performance';
    let renderCanvas = webglCanvas;
    if (offscreen.checked) {
      if (transferControlToOffscreen.checked) {
        renderCanvas = webglCanvas.transferControlToOffscreen();
      } else {
        glBitmapRenderer = webglCanvas.getContext('bitmaprenderer')
        renderCanvas = glOffscreenCanvas = new OffscreenCanvas(size, size);
      }
    }
    renderCanvas.width = renderCanvas.height = size;
    if (useWebGL2.checked)
      gl = renderCanvas.getContext('webgl2', options);
    if (gl) {
      webglVersion = 2;
    } else {
      webglVersion = 1;
      gl = renderCanvas.getContext('webgl', options);
      const aia = gl.getExtension('ANGLE_instanced_arrays');
      if (aia)
        gl.drawArraysInstanced = (a, b, c, d)=>aia.drawArraysInstancedANGLE(a, b, c, d);
      else {
        pixels.value = pixels.min;
        pixelsWrapper.style.display = 'none';
        gl.drawArraysInstanced = (a, b, c, d)=>gl.drawArrays(a, b, c);
      }
    }
    // Read context info like renderer string and extensions.
    let renderer = gl.getParameter(gl.RENDERER);
    let debugRendererInfo = gl.getExtension('WEBGL_debug_renderer_info');
    if (debugRendererInfo)
      renderer = gl.getParameter(debugRendererInfo.UNMASKED_RENDERER_WEBGL);
    contextVersion.textContent = `WebGL Version: ${gl.getParameter(gl.VERSION)}\nRenderer: `;
    const a = document.createElement('a');
    a.textContent = renderer;
    a.href = `https://www.google.com/search?q=${encodeURIComponent(renderer)}`
    contextVersion.appendChild(a);
    contextAttributes.getElementsByTagName('pre')[0].textContent = JSON.stringify(gl.getContextAttributes(), 0, 2);
    for (const e of gl.getSupportedExtensions()) {
      const a = document.createElement('a');
      a.textContent = e;
      a.href = `https://www.khronos.org/registry/webgl/extensions/${e}/`;
      supportedExtensions.appendChild(a);
      supportedExtensions.appendChild(document.createElement('br'));
    }

    // Setup texture
    const tex = gl.createTexture();
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 255]));
    img.onload = ()=>{
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
      gl.generateMipmap(gl.TEXTURE_2D);
      setTimeout(render, 0);
    };
    if (img.src && img.complete) img.onload();

    function setupProgram(vsSource, fsSource, attribs, uniforms) {
      let prog = gl.createProgram();
      function compileShader(source, type) {
        const shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
          console.error(gl.getShaderInfoLog(shader));
        gl.attachShader(prog, shader);
      }
      compileShader(vsSource, gl.VERTEX_SHADER);
      compileShader(fsSource, gl.FRAGMENT_SHADER);
      for (let i = 0; i < attribs.length; ++i)
        gl.bindAttribLocation(prog, i, attribs[i]);
      gl.linkProgram(prog);
      if (!gl.getProgramParameter(prog, gl.LINK_STATUS))
        console.error(gl.getProgramInfoLog(prog));
      for (const attrib of attribs)
        prog[attrib] = gl.getAttribLocation(prog, attrib);
      for (const uniform of uniforms)
        prog[uniform] = gl.getUniformLocation(prog, uniform);
      return prog;
    }

    program = setupProgram(`
      attribute vec2 position;
      varying vec2 texCoord;
      uniform vec2 offset;
      uniform float size;
      void main() {
        gl_Position = vec4(position * size + offset + vec2(size - 1., 1. - size), 0, 1);
        texCoord = vec2(position.x, 1. - position.y);
      }`,`
      precision mediump float;
      varying vec2 texCoord;
      uniform sampler2D tex;
      void main() {
        gl_FragColor = texture2D(tex, texCoord);
      }`,
      ['position', 'texCoordIn'], ['offset', 'tex', 'size']);
    gl.useProgram(program);
    gl.uniform1i(program.tex, 0);

    // Setup vertex buffer
    buffer = gl.createBuffer();
    bufferForSubData = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STREAM_DRAW);
    gl.enableVertexAttribArray(program.position);
    gl.vertexAttribPointer(program.position, 2, gl.FLOAT, false, 0, 0);

    // Setup drawing state
    gl.viewport(0, 0, size, size);
    gl.clearColor(1, 1, 1, 1);
    gl.disable(gl.SCISSOR_TEST);
    gl.disable(gl.DEPTH_TEST);
    gl.disable(gl.STENCIL_TEST);
    gl.disable(gl.BLEND);
    gl.disable(gl.CULL_FACE);

    updateControls();
  }

  // End WebGL init.
  // Begin WebGL rendering.

  // Upload data to buffers if requested by the user.
  if (bufferData.value > 0) {
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, bufferDataArray, gl.STREAM_DRAW);
  }

  if (mapAsyncArray.length > 0) {
    // WebGL doesn't have mapAsync, so just do bufferData
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, mapAsyncArray, gl.STREAM_DRAW);
  }
  if (bufferSubDataArray.length > 0) {
    gl.bindBuffer(gl.ARRAY_BUFFER, bufferForSubData);
    if (bufferForSubDataSize != bufferSubDataArray.byteLength) {
      bufferForSubDataSize = bufferSubDataArray.byteLength;
      // Make the buffer bigger than the upload so the driver doesn't try to optimize it into a bufferData call. Just in case.
      gl.bufferData(gl.ARRAY_BUFFER, bufferForSubDataSize + 1, gl.DYNAMIC_DRAW)
    }
    gl.bufferSubData(gl.ARRAY_BUFFER, 0, bufferSubDataArray);
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  }

  // Actually draw the texture.
  // Set up to scissor out all but one pixel of the texture.
  gl.scissor(position[0], size - position[1] - imgSize, 2, 2);

  let usedProgram = program;
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);

  gl.useProgram(usedProgram);
  gl.uniform2f(usedProgram.offset,
               (position[0] - imgSize) / size * 2,
               -position[1] / size * 2);
  gl.uniform1f(usedProgram.size, imgSize * 2 / size);

  let scissorEnabled = false;
  gl.clearColor(1, 1, 1, 1);
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instancesFirstCall);
  var instancesDrawn = instancesFirstCall;
  for (let i = 1; i < numDrawCalls; i++) {
    if (instancesDrawn > instances && !scissorEnabled) {
      // If we've drawn all of the requested pixels already, enable the scissor
      // test so we only draw one pixel per draw call for the rest of the calls.
      scissorEnabled = true;
      gl.enable(gl.SCISSOR_TEST);
    }
    gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instancesPerCall);
    instancesDrawn += instancesPerCall;
  }
  gl.disable(gl.SCISSOR_TEST);

  if (finish.checked) {
    gl.finish();
  }
  if (readPixels.checked) {
    gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, readPixelsArray);
  }
  if (offscreen.checked && !transferControlToOffscreen.checked && glOffscreenCanvas && glBitmapRenderer) {
    glBitmapRenderer.transferFromImageBitmap(glOffscreenCanvas.transferToImageBitmap());
  }
}


/**************\
* FPS counters *
\**************/


const counters = {};
function countFps(name, mouseEventsThisFrame) {
  let counter = counters[name];
  if (!counter) {
    counter = { history: [Date.now() - 16], name: name, count: 0 };
    counters[name] = counter;
  }
  const history = counter.history;
  history.push(Date.now());
  while (history.length > 2 &&
      history[0] + 1000 < history[history.length - 1]) {
    counter.history.shift();
  }
  let averageMs = 0;
  let maxMs = .1;
  let minMs = 99999999;
  for (let i = 1; i < history.length; i++) {
    let diff = history[i] - history[i - 1];
    averageMs += diff;
    maxMs = Math.max(maxMs, diff);
    minMs = Math.min(minMs, diff);
  }
  averageMs /= history.length - 1;
  counter.fps = 1000 / averageMs;
  counter.minFps = 1000 / maxMs;
  counter.maxFps = 1000 / minMs;
  counter.count++;

  if (mouseEventsThisFrame !== undefined) {
    counter.mouseEvents = counter.mouseEvents || { multiple: 0, zero: 0 };
    if (mouseEventsThisFrame > 1) {
      counter.mouseEvents.multiple++;
    } else if (mouseEventsThisFrame == 0) {
      counter.mouseEvents.zero++;
    }
  }

  if (showFps.checked) {
    fpsSpan.textContent = counters['render'].fps.toFixed();
    if (showStats.checked) {
      let text = "";
      for (let key in counters) {
        counter = counters[key];
        text += "<b>" + counter.name + "</b><br>";
        text += counter.fps.toFixed() + " avg FPS<br>";
        text += "<div class=" +
                (counter.maxFps - counter.fps > 10 ? "bad" : "") + ">" +
                counter.maxFps.toFixed() + " max FPS</div>";
        text += "<div class=" +
                (counter.fps - counter.minFps > 10 ? "bad" : "") + ">" +
                counter.minFps.toFixed() + " min FPS</div>";
        if (counter.mouseEvents) {
          text += "<div>" + counter.mouseEvents.zero +
                  " frame(s) with no mouse/touch events";
          text += "<div>" + counter.mouseEvents.multiple +
                  " frame(s) with multiple mouse/touch events";
        }
        text += "<div class=light>" + counter.count + " frames</div>";
      }
      stats.innerHTML = text;
    }
  }
}

updateControls();
render();

img.src = '';
</script>
